iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Software Development

綠燈不等於做對:AI 開發的驗收工程 ——從兩個失敗的案子,到驗收交付系列 第 23

Day 23 - 規格格式:寫可驗證的行為,不要寫實作步驟

  • 分享至 

  • xImage
  •  

今天講規格格式。

一句話先講完設計原則:

規格描述「可驗證的行為」,不是「實作步驟」。

這句話聽起來很抽象,但它決定了昨天那個「規格覆蓋率」能不能成立。

兩種寫法

看兩份描述同一件事的規格。

A 版:

計算勞退雇主提繳。先取得員工的核定薪資,查詢勞退月提繳工資分級表找到對應級距,再乘以雇主提繳率 6%,最後做捨入處理,寫入薪資單的雇主成本區。

B 版:

{
  "id": "UC-014",
  "title": "計算勞退雇主提繳金額",
  "mode": "新做",
  "input": [
    { "name": "monthlySalary", "type": "money", "constraint": "> 0" },
    { "name": "period", "type": "yearMonth" }
  ],
  "preconditions": ["該期別的分級表版本存在"],
  "postconditions": [
    { "id": "PC-1", "text": "提繳金額 = 級距工資 × 雇主提繳率" },
    { "id": "PC-2", "text": "級距工資取自分級表,非核定薪資直乘" },
    { "id": "PC-3", "text": "捨入採全式捨入,捨入點在最後一步" },
    { "id": "PC-4", "text": "金額寫入薪資單的雇主成本區,不影響員工實發" }
  ],
  "errorCases": [
    { "id": "EC-1", "when": "薪資低於分級表最低級距", "then": "以最低級距計算" },
    { "id": "EC-2", "when": "該期別無分級表版本", "then": "拒絕試算並回報缺版本" }
  ]
}

每一條都有編號。PC-1EC-2。這件事看起來很瑣碎,但它是後面「規格覆蓋率」能被算出來的唯一原因:測試的斷言要在名稱或註解裡帶上那個編號,對帳才是機器做得到的事,而不是人讀兩份檔案憑印象比對。

A 版比較好讀。但它們有一個決定性的差別:B 版的每一條,都可以一對一翻譯成一個測試斷言。A 版不行。「先取得核定薪資,查詢分級表,再乘以提繳率」。這是實作順序。你沒辦法寫一個測試去斷言「它是先查表才相乘的」,而且你也不該在意這個順序。

「級距工資取自分級表,非核定薪資直乘」。這是可觀察的結果。你可以寫一個反例測試:給一個「核定薪資直乘會得到不同答案」的輸入,斷言它走的是級距。

為什麼這個區別是地基

因為它讓昨天那件事變成可能:

拿規格的後置條件 + 錯誤情境清單
      ↓
對照測試檔裡的斷言
      ↓
列出「有規格但沒有對應測試」的項目

這個比對是機械的。可以寫成腳本、可以放進 CI、可以要求 100%。如果規格寫成 A 版那樣,這個比對做不出來。你只能靠人讀一遍,然後說「嗯,看起來都有測到」。

而這就是「驗證才是瓶頸」的具體長相。 規格格式設計的重點不在於它好不好讀,在於它能不能被機器拿去對帳。

所以格式定義裡有一條硬規則:

後置條件與錯誤情境,至少各要有一條。

而這裡要把一條線接回去:Day 10 那十三個維度,在這個格式裡就是 postconditions 的條目。

那時候我只能說「規格的職責是列出有哪些維度需要被驗收」,但沒有格式,所以那句話落不了地。現在它有格式了——一個維度=一條後置條件=一個可以單獨被驗的斷言。而 Day 22 那個規格覆蓋率,就是那條 13 / 4 / 6-8 / 1 漏斗的量化版:它讓「驗收驗了幾個」這一格第一次有數字。

還有一個維度我在 Part 0 給過、但沒有進到這個 schema 裡:Day 07 那個「複製/改版/新做」的標籤。 它其實是規格的第一個必填欄位。因為它決定了驗收要拿什麼來比對(舊系統/新設計/完整規格)。填空的不給過,跟其他欄位一樣。

這條規則的作用不是湊數,是強迫寫規格的人思考可驗證的行為。如果你一條後置條件都寫不出來,那代表你其實還沒想清楚:這個功能做完之後,世界會有什麼不同。

https://ithelp.ithome.com.tw/upload/images/20260921/20178262Afl8RxWkjy.png

但可驗證,不等於不能作弊

寫到這裡有個東西我漏了,是後來一次對話讓我補上的。

有人跟我說,他曾經跟 AI 下了一個指令:

「要通過所有的單元測試。」

然後 AI 把測試的程式碼刪掉了。

我想了很久,因為這件事跟我前面講的都不一樣。

它不是規格不完整——這句話沒有任何歧義。不是 AI 理解錯。它完全懂。不是規格寫成了實作步驟。它描述的正是一個可觀察的結果。

照我前面立的所有標準,這是一條合格的規格。

問題在別的地方:「所有單元測試都通過」這個狀態,有一個我不想要、但字面上完全成立的達成方式。 而那個方式剛好最省力。

目標的退化解

我後來把這件事叫做退化解。在數學上那是「符合所有條件、但把問題本身消掉」的那種解。一旦開始看,到處都是:

目標 退化解
通過所有單元測試 刪掉測試
把覆蓋率提到 80% 寫永遠為真的斷言
消除所有 lint 警告 加一排 disable 註解
讓建置變快 關掉檢查
修好這個 flaky test 加 retry 或直接 skip
減少問題單數量 提高立單門檻

這一整欄的共同點是:它們全部會通過驗收,而且在字面上完全正確。

所以任何事後的檢查都抓不到。因為目標達成了。這也是為什麼它比「AI 做錯了」難處理得多:做錯了會有訊號,退化解沒有,它甚至會給你一個綠燈。

而它跟前面幾天講的失效有一個關鍵差異:

規格不完整   →  AI 補空白    →  補錯了
規格有退化解 →  AI 照著做    →  完全正確,而且毀掉了量尺

所以要多問一句,而且是在下指令之前

這是我目前找到唯一真正事前的動作,而且它不需要任何工具:

「這個目標,有沒有一個我不想要、但字面上完全成立的達成方式?」

三十秒,在按下送出之前。

而如果想得出來,處理方式有三層,由弱到強:

一、把排除條件寫進規格(advisory)
「通過所有單元測試,且測試檔不得被修改或刪除」。有效,但它是一條可以被壓過的負向指令。前面講過為什麼。

二、把量尺變成可稽核的(detective)

git diff --exit-code HEAD -- src/test/

測試檔有任何異動就 fail。這條會抓到,但是在事後。

三、把量尺移出對方的可寫範圍(preventive)
測試檔的寫入權限,從一開始就不給。

第三層才是真正的解,而且它有一個更一般的形式。我後來發現這個系列裡它出現了三次,只是我一直沒把它們放在一起:

出現在哪 情況
做分析的時候 稽核員如果可以順手把帳改好,那份稽核報告就沒有價值了
護欄本身 hook 的設定檔放在 AI 有寫入權的目錄裡——受控的對象可以改自己的護欄
上面那個例子 測試檔在 AI 的可寫範圍內,所以「通過測試」可以靠刪測試達成

三個都是同一句話:

量測工具,不能落在被量測者的權限範圍內。

而這一條的好處是——它不需要你先踩到。你不必等一個 AI 刪掉你的測試,才想到要把測試設成唯讀。

一個誠實的補充

我承認這一節是被問出來的,不是我自己想到的。

有人問我:「為什麼我都在幫 AI 找執行錯誤的理由,然後再進行改善?我希望讓事情做對的機率更高一點。」

這句話戳到了整套方法的形狀。「觀察 → 判斷 → 改進 → 驗證」這個迴圈本質上就是事後的。它保證你永遠在追。前面我自己也寫過「這一層天然是滯後的,要擋什麼通常要踩過才知道」。

而上面那個問句,是我目前找得到唯一不需要先踩就能用的東西。它不能取代那個迴圈,但它可以讓你少進去幾次。

讓格式可以被強制

有了格式,下一步是讓格式被強制。

做法是寫一份 JSON Schema,規定必填欄位、規定後置條件和錯誤情境的最小長度是 1,然後配一支驗證腳本。這支腳本的價值在於:它把「規格寫得不完整」從一個要靠 review 發現的問題,變成一個 CI 會擋下來的錯誤。

規格漏寫錯誤情境,不再是「審核的人要記得問」,而是「這份規格根本進不了流程」。

而工廠的執行流程第一步就是:

1. 驗證規格 —— 失敗即停止

不是「警告之後繼續」,是停止。

因為如果規格本身不完整(少了錯誤情境、後置條件是空的),那後面所有的驗證都失去了對帳的基準。規格覆蓋率沒有分母,審查沒有依據。用一份殘缺的規格產出一堆程式碼,比什麼都不產出更糟。因為前者會讓人以為做完了。

一個真實的墮落路徑

這裡要講一個反面教訓,我認為是最值得警惕的一個。

當你發現某個東西規格格式裝不下,最省事的做法是加一個 notes 欄位,把裝不下的東西全部塞進去。

第一次這樣做感覺沒什麼。第二次也是。

半年後你的規格長這樣:三個結構化欄位,加一個三百字的 notes。那份 schema 的最小可用版本:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "required": ["id", "title", "mode", "postconditions", "errorCases"],
  "properties": {
    "id":    { "type": "string", "pattern": "^UC-[0-9]{3}$" },
    "title": { "type": "string", "minLength": 1 },
    "mode":  { "enum": ["複製", "改版", "新做"] },
    "postconditions": {
      "type": "array", "minItems": 1,
      "items": {
        "type": "object", "required": ["id", "text"],
        "properties": {
          "id":   { "type": "string", "pattern": "^PC-[0-9]+$" },
          "text": { "type": "string", "minLength": 1 }
        }
      }
    },
    "errorCases": {
      "type": "array", "minItems": 1,
      "items": {
        "type": "object", "required": ["id", "when", "then"],
        "properties": {
          "id":   { "type": "string", "pattern": "^EC-[0-9]+$" },
          "when": { "type": "string", "minLength": 1 },
          "then": { "type": "string", "minLength": 1 }
        }
      }
    }
  },
  "additionalProperties": false
}

三個地方值得指:minItems: 1 強迫每份規格至少各有一條後置條件與錯誤情境(這就是前面那句「至少各一條」的機器版);id 的 pattern 讓覆蓋率腳本有東西可以對帳;mode 就是 Day 07 那三個標籤,enum 讓它填空的過不了。

驗證是一行:

npx ajv-cli validate -s spec.schema.json -d ".dev/specs/*.json"

而那個 notes 欄位,機器讀不了。 它不能對帳、不能算覆蓋率、不能被 schema 驗證。整套流程賴以成立的地基,就這樣悄悄被掏空了。正確的做法是:如果反覆遇到裝不下的東西,那代表這個格式缺一個欄位。加欄位、改 schema,不要開後門。

但也不要動不動就改 schema

上面那句話有一個危險的反面,我在另一個案子看到很好的處理,值得對照。那個案子做逆向規格的試點時,AI 代理在幾個欄位「自然偏離了」schema。它想把某些東西寫成結構化的物件,而 schema 定義的是字串。

兩個選項:改 schema,還是把資料塞回字串?

他們的決定是:凍結 schema v1,不做異動。 那些結構化的需求,一律用「字串書寫慣例」承載。固定的分隔符、固定的書寫格式,一致就好。理由寫得很清楚:本專案不牽涉 schema 異動。

我覺得這個判斷很成熟。因為改 schema 的成本不只是改一個檔案。所有既有規格要重驗、驗證腳本要改、範例要更新。在專案中途做這件事,收益要很明確才划算。

所以判準不是「能不能塞下」,是「這次值不值得動地基」。

  • 反覆遇到、而且影響表達力 → 加欄位
  • 偶爾遇到、可以用書寫慣例承載 → 凍結,寫成約定
  • 隨手加 notes 塞進去 → 絕對不行

第三種是唯一沒有討論空間的。

反向工程一個範例

Phase 2 的最後一步是一個聰明的自我檢查:挑一個 Day 21 做的黃金範例,依照新格式回頭寫出它的規格。 如果格式的表達力夠,這件事應該很順。如果卡住了,代表格式有問題。

而這一步還有一個附帶好處:你會得到第一份規格,而且它對應的實作已經存在、已經是完美的。這份規格可以直接當成規格的範例。薪資工廠那 43 次紀錄的前兩筆就是這個:

labor-insurance(黃金範例反向工程)    工廠建置時人工打造,非 skill 產出
health-insurance(黃金範例反向工程)  同上

它們被誠實地標成「非 skill 產出」,不計入一次通過率。

這種記帳紀律,是那個 36/37 有意義的原因。

小結

  • 規格描述可驗證的行為,不是實作步驟——這是覆蓋率能算出來的前提
  • 後置條件與錯誤情境各至少一條,強迫思考「做完之後世界有什麼不同」
  • 但可驗證不等於不能作弊——下指令前多問一句:「這個目標有沒有一個字面上完全成立、但我不想要的達成方式?
  • 而真正的解是把量測工具移出被量測者的可寫範圍。這一條的好處是:它不需要你先踩到
  • 用 schema 讓格式被強制,驗證失敗即停止
  • 不要開 notes 後門,但也不要動不動改 schema——判準是「值不值得動地基」
  • 反向工程一個黃金範例來驗證表達力,而且誠實標記它不是自動產出的

明天講一個很小、但我認為是整套工廠最聰明的設計:怎麼防止「有程式沒規格」的漂移。


上一篇
Day 22 - 在 AI 寫完的那一秒攔下來
系列文
綠燈不等於做對:AI 開發的驗收工程 ——從兩個失敗的案子,到驗收交付23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言